Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\client\mod.rs
Line
Count
Source
1
//! Client implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use log::{error, info, warn};
8
use std::fs::File;
9
use std::io::{self, BufReader};
10
use std::path::Path;
11
use std::process::ExitStatus;
12
use std::time::Duration;
13
use windows::Win32::UI::Input::KeyboardAndMouse::{VIRTUAL_KEY, VK_C, VK_CANCEL};
14
15
use crate::utils::config::ClientConfig;
16
use crate::utils::windows::{get_console_title, set_console_color, WindowsApi};
17
use ssh2_config::{ParseRule, SshConfig};
18
use tokio::net::windows::named_pipe::NamedPipeClient;
19
use tokio::process::{Child, Command};
20
use tokio::sync::watch;
21
use tokio::{io::Interest, net::windows::named_pipe::ClientOptions};
22
use windows::Win32::System::Console::{
23
    CONSOLE_CHARACTER_ATTRIBUTES, ENABLE_PROCESSED_INPUT, INPUT_RECORD, INPUT_RECORD_0, KEY_EVENT,
24
    KEY_EVENT_RECORD, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED,
25
    SHIFT_PRESSED, STD_INPUT_HANDLE,
26
};
27
28
use cssh_rs_protocol::{
29
    deserialization::parse_daemon_to_client_messages, serialization::serialize_pid, ClientState,
30
    DaemonToClientMessage, SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH,
31
};
32
33
use cssh_rs_meta::PACKAGE_NAME;
34
35
use crate::utils::constants::PIPE_NAME;
36
37
/// Possible results when reading from the named pipe and writing to the
38
/// current process's stdinput.
39
enum ReadWriteResult {
40
    /// We wrote all complete [INPUT_RECORD_0] sequences we read from
41
    /// the named pipe to stdin.
42
    Success {
43
        /// Incomplete [INPUT_RECORD_0] sequence.
44
        ///
45
        /// What we read from the named pipe is a serialized [INPUT_RECORD_0].`KeyEvent`.
46
        /// As this is simply a [`SERIALIZED_INPUT_RECORD_0_LENGTH`] byte long sequence and we try to read from the pipe until we
47
        /// have some of the data it can happen that during any one read/write iteration we don't
48
        /// read the full sequence so we must keep track of what we read for next iterations
49
        /// where we will be able to read the remainder of the sequence.
50
        remainder: Vec<u8>,
51
        /// List of [KEY_EVENT_RECORD]s we have read from the named pipe.
52
        ///
53
        /// Used to detect the `Alt + Shift + C` key combination used
54
        /// to close the console window after the client process encountered an unexpected error.
55
        key_event_records: Vec<KEY_EVENT_RECORD>,
56
    },
57
    /// Trying to read from the pipe would require us to wait for data.
58
    WouldBlock,
59
    /// Something went wrong.
60
    Err,
61
    /// The pipe was closed.
62
    Disconnect,
63
}
64
65
/// Duration of the action-feedback flash painted on a highlighted client
66
/// when the user toggles the state.
67
const HIGHLIGHT_FLASH_DURATION: Duration = Duration::from_millis(250);
68
69
/// Grace period for the SSH child to exit after the console interrupt before
70
/// [`shutdown_child`] force-kills it.
71
const CHILD_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(500);
72
73
/// Resolve the console color for a `(state, highlighted)` combination;
74
/// highlight overlays the disabled color.
75
///
76
/// # Arguments
77
///
78
/// * `state`                     - The client's current [`ClientState`].
79
/// * `highlighted`               - `true` while the client is the selected
80
///                                 window in the daemon's enable/disable
81
///                                 submenu.
82
/// * `original_console_color`    - Console color captured at startup.
83
/// * `disabled_console_color`    - Color applied while the client is
84
///                                 [`ClientState::Disabled`].
85
/// * `highlighted_console_color` - Color applied while the client is
86
///                                 highlighted.
87
///
88
/// # Returns
89
///
90
/// The color to paint, or `None` when `original_console_color` is `None`.
91
9
fn get_effective_color(
92
9
    state: ClientState,
93
9
    highlighted: bool,
94
9
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
95
9
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
96
9
    highlighted_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
97
9
) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
98
9
    let 
original6
= original_console_color
?3
;
99
6
    if highlighted {
100
4
        return Some(highlighted_console_color);
101
2
    }
102
2
    match state {
103
1
        ClientState::Active => return Some(original),
104
1
        ClientState::Disabled => return Some(disabled_console_color),
105
    }
106
9
}
107
108
/// Resolve the underlying state color, with the highlight overlay bypassed,
109
/// for the action-feedback flash.
110
///
111
/// # Arguments
112
///
113
/// * `state`                    - The just-applied [`ClientState`].
114
/// * `original_console_color`   - Console color captured at startup.
115
/// * `disabled_console_color`   - Color applied while the client is
116
///                                [`ClientState::Disabled`].
117
///
118
/// # Returns
119
///
120
/// The color to paint, or `None` when `original_console_color` is `None`.
121
5
fn get_flash_color(
122
5
    state: ClientState,
123
5
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
124
5
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
125
5
) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
126
5
    let 
original3
= original_console_color
?2
;
127
3
    match state {
128
2
        ClientState::Active => return Some(original),
129
1
        ClientState::Disabled => return Some(disabled_console_color),
130
    }
131
5
}
132
133
/// Bundle of the three colors [`run_visuals_loop`] chooses between
134
/// when repainting the per-client console.
135
struct ConsolePalette {
136
    /// Color captured before the SSH child wrote anything; `None`
137
    /// degrades every paint to a no-op.
138
    original: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
139
    /// Color applied while [`ClientState::Disabled`].
140
    disabled: CONSOLE_CHARACTER_ATTRIBUTES,
141
    /// Color applied while the client is the highlighted submenu target.
142
    highlighted: CONSOLE_CHARACTER_ATTRIBUTES,
143
}
144
145
/// Repaint the console to the steady-state color for `(state, highlighted)`.
146
///
147
/// # Arguments
148
///
149
/// * `api`         - The Windows API implementation to use.
150
/// * `state`       - The client's current [`ClientState`].
151
/// * `highlighted` - Whether the client is the submenu's highlighted target.
152
/// * `palette`     - The available colors.
153
/// * `last`        - Most recently painted color; updated in place.
154
2
fn paint_steady(
155
2
    api: &dyn WindowsApi,
156
2
    state: ClientState,
157
2
    highlighted: bool,
158
2
    palette: &ConsolePalette,
159
2
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
160
2
) {
161
2
    paint_console_color(
162
2
        api,
163
2
        get_effective_color(
164
2
            state,
165
2
            highlighted,
166
2
            palette.original,
167
2
            palette.disabled,
168
2
            palette.highlighted,
169
        ),
170
2
        last,
171
    );
172
2
}
173
174
/// Paint the action-feedback flash and return the deadline at which
175
/// the steady-state should be restored.
176
///
177
/// # Arguments
178
///
179
/// * `api`     - The Windows API implementation to use.
180
/// * `state`   - The just-applied [`ClientState`].
181
/// * `palette` - The available colors.
182
/// * `last`    - Most recently painted color; updated in place.
183
///
184
/// # Returns
185
///
186
/// The [`tokio::time::Instant`] the flash should be cleared at.
187
1
fn start_flash(
188
1
    api: &dyn WindowsApi,
189
1
    state: ClientState,
190
1
    palette: &ConsolePalette,
191
1
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
192
1
) -> tokio::time::Instant {
193
1
    paint_console_color(
194
1
        api,
195
1
        get_flash_color(state, palette.original, palette.disabled),
196
1
        last,
197
    );
198
1
    return tokio::time::Instant::now() + HIGHLIGHT_FLASH_DURATION;
199
1
}
200
201
/// Paint `target` if it differs from `last`, then update `last`.
202
///
203
/// Skipping unchanged repaints avoids an unnecessary LPC roundtrip to
204
/// conhost and the post-fill `InvalidateRect`/WM_PAINT.
205
///
206
/// # Arguments
207
///
208
/// * `api`    - The Windows API implementation to use.
209
/// * `target` - The color to paint, or `None` to skip.
210
/// * `last`   - The most recently painted color; updated in-place after a
211
///              successful repaint.
212
7
fn paint_console_color(
213
7
    api: &dyn WindowsApi,
214
7
    target: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
215
7
    last: &mut Option<CONSOLE_CHARACTER_ATTRIBUTES>,
216
7
) {
217
7
    let Some(
color6
) = target else {
218
1
        return;
219
    };
220
6
    if last.map(|c| return 
c.04
) == Some(color.0) {
221
1
        return;
222
5
    }
223
5
    set_console_color(api, color);
224
5
    *last = Some(color);
225
7
}
226
227
/// Write the given [INPUT_RECORD_0] to the console input buffer using the provided API.
228
///
229
/// # Arguments
230
///
231
/// * `api` - The Windows API implementation to use.
232
/// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` input record to write.
233
7
fn write_console_input(api: &dyn WindowsApi, input_record: INPUT_RECORD_0) {
234
7
    let buffer: [INPUT_RECORD; 1] = [INPUT_RECORD {
235
7
        EventType: KEY_EVENT as u16,
236
7
        Event: input_record,
237
7
    }];
238
7
    let mut nb_of_events_written = 0u32;
239
7
    match api.write_console_input(&buffer, &mut nb_of_events_written) {
240
        Ok(_) => {
241
6
            if nb_of_events_written == 0 {
242
1
                error!("Failed to write console input");
243
1
                error!("{:?}", 
api0
.
get_last_error0
());
244
5
            }
245
        }
246
        Err(_) => {
247
1
            error!("Failed to write console input");
248
1
            error!("{:?}", 
api0
.
get_last_error0
());
249
        }
250
    };
251
7
}
252
253
/// Report whether a forwarded key event is Ctrl+C or Ctrl+Break.
254
///
255
/// # Arguments
256
///
257
/// * `key_event` - The key event record forwarded by the daemon.
258
12
fn is_console_signal_key(key_event: &KEY_EVENT_RECORD) -> bool {
259
12
    return key_event.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED) != 0
260
9
        && 
matches!1
(VIRTUAL_KEY(key_event.wVirtualKeyCode), VK_C | VK_CANCEL);
261
12
}
262
263
/// Report whether the console input buffer has `ENABLE_PROCESSED_INPUT` set,
264
/// treating any query failure as not set.
265
///
266
/// # Arguments
267
///
268
/// * `api` - The Windows API implementation to use.
269
5
fn processed_input_enabled(api: &dyn WindowsApi) -> bool {
270
5
    let Ok(handle) = api.get_std_handle(STD_INPUT_HANDLE) else {
271
0
        return false;
272
    };
273
5
    return match api.get_console_mode(handle) {
274
4
        Ok(mode) => mode.0 & ENABLE_PROCESSED_INPUT.0 != 0,
275
1
        Err(_) => false,
276
    };
277
5
}
278
279
/// Replay one daemon-forwarded key event into the client console.
280
///
281
/// Ctrl+C and Ctrl+Break injected via `WriteConsoleInputW` never raise a
282
/// console control signal, so when the input buffer has processed input
283
/// enabled they are re-raised via `GenerateConsoleCtrlEvent`; all other
284
/// records (and raw-mode consoles) are written verbatim.
285
///
286
/// # Arguments
287
///
288
/// * `api`          - The Windows API implementation to use.
289
/// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` record to replay.
290
7
fn replay_input_record(api: &dyn WindowsApi, input_record: INPUT_RECORD_0) {
291
7
    let key_event = unsafe { input_record.KeyEvent };
292
7
    if is_console_signal_key(&key_event) && 
processed_input_enabled5
(
api5
) {
293
        // Re-raise on key-down only, dropping both records so no literal Ctrl+C
294
        // reaches the child. Group 0 signals this client too, but the startup
295
        // handler shields it.
296
3
        if key_event.bKeyDown.as_bool() {
297
2
            if let Err(
err0
) = api.interrupt_console_process_group() {
298
0
                warn!("Failed to relay console control event: {}", err);
299
2
            }
300
1
        }
301
3
        return;
302
4
    }
303
4
    write_console_input(api, input_record);
304
7
}
305
306
/// Resolve the username from the provided value or SSH config.
307
///
308
/// # Arguments
309
///
310
/// * `username` - Optional username to use. If None, will try to resolve from SSH config.
311
/// * `host` - The hostname (without port) to connect to.
312
/// * `config` - The client configuration containing SSH config path.
313
///
314
/// # Returns
315
///
316
/// The resolved username.
317
12
fn resolve_username(username: Option<String>, host: &str, config: &ClientConfig) -> String {
318
12
    if let Some(
val8
) = username {
319
8
        return val;
320
4
    }
321
322
4
    let mut ssh_config = SshConfig::default();
323
4
    let ssh_config_path = Path::new(config.ssh_config_path.as_str());
324
4
    if ssh_config_path.exists() {
325
2
        let mut reader = BufReader::new(
326
2
            File::open(ssh_config_path).expect("Could not open SSH configuration file."),
327
2
        );
328
2
        ssh_config = SshConfig::default()
329
2
            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
330
2
            .expect("Failed to parse SSH configuration file");
331
2
    }
332
4
    return ssh_config
333
4
        .query(<&str>::clone(&host))
334
4
        .user
335
4
        .unwrap_or_default();
336
12
}
337
338
/// Build the SSH arguments from the username, host, port, and config.
339
///
340
/// # Arguments
341
///
342
/// * `username`    - The username to connect with.
343
/// * `host`        - The hostname to connect to.
344
/// * `port`        - Optional port number (0-65535).
345
/// * `config`      - The client config indicating how to call the SSH program.
346
///
347
/// # Returns
348
///
349
/// A vector of arguments ready to be passed to the SSH command.
350
12
fn build_ssh_arguments(
351
12
    username: &str,
352
12
    host: &str,
353
12
    port: Option<u16>,
354
12
    config: &ClientConfig,
355
12
) -> Vec<String> {
356
12
    let username_host = format!("{username}@{host}");
357
358
12
    let mut arguments = replace_argument_placeholders(
359
12
        &config.arguments,
360
12
        &config.username_host_placeholder,
361
12
        &username_host,
362
    );
363
364
    // Add port arguments if port was specified
365
12
    if let Some(
port9
) = port {
366
9
        arguments.push("-p".to_string());
367
9
        arguments.push(port.to_string());
368
9
    
}3
369
370
12
    return arguments;
371
12
}
372
373
/// Launch the SSH process.
374
///
375
/// The process might overwrite the console title once it launched, so we wait for that
376
/// to happen and set the title again.
377
///
378
/// # Arguments
379
///
380
/// * `username`    - The username to connect with.
381
/// * `host`        - The hostname to connect to.
382
/// * `port`        - Optional port number (0-65535).
383
/// * `config`      - The client config indicating how to call the SSH program.
384
///
385
/// # Returns
386
///
387
/// The handle to created [Child] process.
388
0
async fn launch_ssh_process(
389
0
    username: &str,
390
0
    host: &str,
391
0
    port: Option<u16>,
392
0
    config: &ClientConfig,
393
0
) -> Child {
394
0
    let arguments = build_ssh_arguments(username, host, port, config);
395
0
    let child = Command::new(&config.program)
396
0
        .args(arguments.clone())
397
0
        // Backstop for paths that bypass `shutdown_child` (e.g. a panic).
398
0
        .kill_on_drop(true)
399
0
        .spawn()
400
0
        .unwrap_or_else(|err| {
401
0
            let args: String = arguments.join(" ");
402
0
            error!("{}", err);
403
0
            panic!(
404
                "Failed to launch process `{}` with arguments `{}`",
405
                config.program, args
406
            )
407
        });
408
0
    return child;
409
0
}
410
411
/// Testable view of the SSH child process used by [`shutdown_child`].
412
trait ChildProcess {
413
    /// Wait until the child exits.
414
    ///
415
    /// # Returns
416
    ///
417
    /// The child's [`ExitStatus`], or the error reported while waiting.
418
    async fn wait(&mut self) -> io::Result<ExitStatus>;
419
420
    /// Force-kill the child and wait until it has terminated.
421
    ///
422
    /// # Returns
423
    ///
424
    /// `Ok(())` once terminated, or the error reported by the kill.
425
    async fn kill(&mut self) -> io::Result<()>;
426
}
427
428
impl ChildProcess for Child {
429
0
    async fn wait(&mut self) -> io::Result<ExitStatus> {
430
0
        return Child::wait(self).await;
431
0
    }
432
433
0
    async fn kill(&mut self) -> io::Result<()> {
434
0
        Child::start_kill(self)?;
435
0
        Child::wait(self).await?;
436
0
        return Ok(());
437
0
    }
438
}
439
440
/// Guarantee the SSH child terminates once the client's run loop has ended.
441
///
442
/// Interrupts the console process group for a graceful exit, then force-kills
443
/// after [`CHILD_EXIT_GRACE_PERIOD`]; children that handle the signal themselves
444
/// (e.g. cmd.exe) would otherwise keep the client window open forever.
445
///
446
/// # Arguments
447
///
448
/// * `api`   - The Windows API implementation to use.
449
/// * `child` - Handle to the SSH child process.
450
3
async fn shutdown_child(api: &dyn WindowsApi, child: &mut impl ChildProcess) {
451
    // Interrupt the whole group so even a child that ignores Ctrl+C exits; the
452
    // startup handler shields this client so it survives to force the kill.
453
3
    if let Err(
err0
) = api.interrupt_console_process_group() {
454
0
        warn!("Failed to interrupt console process group: {}", err);
455
3
    }
456
3
    match tokio::time::timeout(CHILD_EXIT_GRACE_PERIOD, child.wait()).await {
457
1
        Ok(Ok(exit_status)) => {
458
1
            info!("Child exited after console interrupt: {}", exit_status);
459
1
            return;
460
        }
461
1
        Ok(Err(err)) => {
462
1
            warn!("Failed to wait for child exit: {}", err);
463
        }
464
        Err(_) => {
465
1
            warn!(
466
                "Child still running {}ms after console interrupt; force-killing it",
467
0
                CHILD_EXIT_GRACE_PERIOD.as_millis()
468
            );
469
        }
470
    }
471
2
    if let Err(
err0
) = child.kill().await {
472
0
        error!("Failed to kill child process: {}", err);
473
2
    }
474
2
    return;
475
3
}
476
477
/// Read all available daemon-to-client messages from the named pipe and apply them.
478
///
479
/// Input records are written to the console input buffer using the provided API
480
/// and their key-event payloads are returned via `ReadWriteResult::Success` so
481
/// the caller can detect the Alt+Shift+C close combination. State-change frames
482
/// are forwarded via [`watch::Sender::send_replace`] on `state_sender`, making the
483
/// authoritative [`ClientState`] visible to every watch subscriber (currently
484
/// the visuals task in [`main`]) without coupling this loop to any
485
/// state-dependent rendering. Keep-alive frames are ignored. Partial trailing
486
/// frames are returned as `remainder` for the next call to prepend.
487
///
488
/// # Arguments
489
///
490
/// * `api`                 - The Windows API implementation to use.
491
/// * `named_pipe_client`   - The [Windows named pipe][1] client that has successfully connected to
492
///                           the named pipe created by the daemon.
493
/// * `internal_buffer`     - Vector containing the unconsumed bytes (possibly an
494
///                           incomplete trailing frame) from a previous call.
495
/// * `state_sender`            - Watch sender used to broadcast every
496
///                           [`DaemonToClientMessage::StateChange`] payload as
497
///                           the client's authoritative [`ClientState`].
498
/// * `highlight_sender`        - Watch sender used to broadcast every
499
///                           [`DaemonToClientMessage::Highlight`] payload as
500
///                           the client's current highlight flag.
501
/// # Returns
502
///
503
/// A `ReadWriteResult` indicating whether we were able to read from the named pipe and write the available INPUT_RECORDs
504
/// to the console input buffer or not.
505
///
506
/// [1]: https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes
507
3
async fn read_write_loop(
508
3
    api: &dyn WindowsApi,
509
3
    named_pipe_client: &NamedPipeClient,
510
3
    internal_buffer: &mut Vec<u8>,
511
3
    state_sender: &watch::Sender<ClientState>,
512
3
    highlight_sender: &watch::Sender<bool>,
513
3
) -> ReadWriteResult {
514
3
    let mut buf: [u8; SERIALIZED_INPUT_RECORD_0_LENGTH * 10] =
515
3
        [0; SERIALIZED_INPUT_RECORD_0_LENGTH * 10];
516
3
    match named_pipe_client.try_read(&mut buf) {
517
        Ok(0) => {
518
            // Seems to only happen if the pipe is closed/server disconnects
519
            // indicating that the daemon has been closed.
520
            // Exit the client too in that case.
521
0
            return ReadWriteResult::Disconnect;
522
        }
523
3
        Ok(n) => {
524
3
            internal_buffer.extend_from_slice(&buf[..n]);
525
3
            let (messages, remainder) = parse_daemon_to_client_messages(internal_buffer);
526
3
            let mut key_event_records: Vec<KEY_EVENT_RECORD> = Vec::new();
527
4
            for message in 
messages3
{
528
4
                match message {
529
1
                    DaemonToClientMessage::InputRecord(input_record) => {
530
1
                        replay_input_record(api, input_record);
531
1
                        key_event_records.push(unsafe { input_record.KeyEvent });
532
1
                    }
533
2
                    DaemonToClientMessage::StateChange(state) => {
534
2
                        state_sender.send_replace(state);
535
2
                    }
536
1
                    DaemonToClientMessage::Highlight(highlighted) => {
537
1
                        highlight_sender.send_replace(highlighted);
538
1
                    }
539
0
                    DaemonToClientMessage::KeepAlive => {}
540
                }
541
            }
542
3
            return ReadWriteResult::Success {
543
3
                remainder,
544
3
                key_event_records,
545
3
            };
546
        }
547
0
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
548
0
            return ReadWriteResult::WouldBlock;
549
        }
550
0
        Err(e) => {
551
0
            error!("{}", e);
552
0
            return ReadWriteResult::Err;
553
        }
554
    }
555
3
}
556
557
/// Checks if a key event represents the Alt+Shift+C combination.
558
///
559
/// # Arguments
560
///
561
/// * `key_event` - The key event record to check.
562
///
563
/// # Returns
564
///
565
/// `true` if the key event represents Alt+Shift+C, `false` otherwise.
566
8
fn is_alt_shift_c_combination(key_event: &KEY_EVENT_RECORD) -> bool {
567
8
    return (key_event.dwControlKeyState & LEFT_ALT_PRESSED >= 1
568
3
        || key_event.dwControlKeyState & RIGHT_ALT_PRESSED == 1)
569
6
        && key_event.dwControlKeyState & SHIFT_PRESSED >= 1
570
5
        && key_event.wVirtualKeyCode == VK_C.0;
571
8
}
572
573
/// Replaces placeholders in SSH command arguments.
574
///
575
/// # Arguments
576
///
577
/// * `arguments` - The argument templates.
578
/// * `placeholder` - The placeholder string to replace.
579
/// * `replacement` - The value to replace the placeholder with.
580
///
581
/// # Returns
582
///
583
/// A vector of arguments with placeholders replaced.
584
12
fn replace_argument_placeholders(
585
12
    arguments: &[String],
586
12
    placeholder: &str,
587
12
    replacement: &str,
588
12
) -> Vec<String> {
589
12
    return arguments
590
12
        .iter()
591
30
        .
map12
(|arg| return arg.replace(placeholder, replacement))
592
12
        .collect();
593
12
}
594
595
/// Send this process's id over the pipe to the daemon as a 4 byte
596
/// little-endian sequence.
597
///
598
/// The daemon uses the PID to match the pipe connection to the correct
599
/// [`crate::daemon`] `Client` entry. Without this handshake the daemon will
600
/// not forward any input records.
601
///
602
/// # Arguments
603
///
604
/// * `named_pipe_client` - The connected pipe client to write the PID to.
605
///
606
/// # Panics
607
///
608
/// Panics if the pipe write fails in a way that cannot be retried.
609
1
async fn send_pid_handshake(named_pipe_client: &NamedPipeClient) {
610
1
    let pid_bytes = serialize_pid(std::process::id());
611
1
    let mut written = 0usize;
612
2
    while written < SERIALIZED_PID_LENGTH {
613
1
        named_pipe_client.writable().await.unwrap_or_else(|err| 
{0
614
0
            panic!("Named pipe client is not writable for PID handshake: {err}")
615
        });
616
1
        match named_pipe_client.try_write(&pid_bytes[written..]) {
617
            Ok(0) => {
618
0
                panic!("Named pipe closed before PID handshake could complete");
619
            }
620
1
            Ok(n) => {
621
1
                written += n;
622
1
            }
623
0
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
624
0
                continue;
625
            }
626
0
            Err(e) => {
627
0
                panic!("Failed to send PID handshake to daemon: {e}");
628
            }
629
        }
630
    }
631
1
    return;
632
1
}
633
634
/// The main run loop of the client.
635
///
636
/// Connects to the named pipe opened by the daemon, reads all input records from it
637
/// and replays them to the console input buffer of the given child process.
638
/// Handles the `Alt + Shift + C` key combination used to close the console window
639
/// after the child process encountered an unexpected error.
640
///
641
/// # Arguments
642
///
643
/// * `api`         - The Windows API implementation to use.
644
/// * `child`       - Handle to the running SSH process.
645
/// * `state_sender`    - Watch sender used by [`read_write_loop`] to broadcast the
646
///                   client's authoritative [`ClientState`] to subscribers
647
///                   such as the visuals task in [`main`].
648
/// * `highlight_sender`- Watch sender used by [`read_write_loop`] to broadcast the
649
///                   client's current highlight flag.
650
0
async fn run(
651
0
    api: &dyn WindowsApi,
652
0
    child: &mut Child,
653
0
    state_sender: &watch::Sender<ClientState>,
654
0
    highlight_sender: &watch::Sender<bool>,
655
0
) {
656
    // Many clients trying to open the pipe at the same time can cause
657
    // a file not found error, so keep trying until we managed to open it
658
0
    let named_pipe_client: NamedPipeClient = loop {
659
0
        match ClientOptions::new().open(PIPE_NAME) {
660
0
            Ok(named_pipe_client) => {
661
0
                break named_pipe_client;
662
            }
663
            Err(_) => {
664
0
                continue;
665
            }
666
        }
667
    };
668
    // Identify ourselves to the daemon's pipe server by sending our PID.
669
    // The daemon uses this to correlate this pipe connection to the corresponding
670
    // client in its internal bookkeeping.
671
0
    send_pid_handshake(&named_pipe_client).await;
672
0
    let mut child_error = false;
673
0
    let mut internal_buffer: Vec<u8> = Vec::new();
674
    loop {
675
0
        named_pipe_client
676
0
            .ready(Interest::READABLE)
677
0
            .await
678
0
            .unwrap_or_else(|err| {
679
0
                error!("{}", err);
680
0
                panic!("Named client pipe is not ready to be read",)
681
            });
682
683
0
        match read_write_loop(
684
0
            api,
685
0
            &named_pipe_client,
686
0
            &mut internal_buffer,
687
0
            state_sender,
688
0
            highlight_sender,
689
        )
690
0
        .await
691
        {
692
            ReadWriteResult::Success {
693
0
                remainder,
694
0
                key_event_records,
695
            } => {
696
0
                internal_buffer = remainder;
697
0
                if child_error {
698
0
                    for key_event in key_event_records.into_iter() {
699
0
                        if is_alt_shift_c_combination(&key_event) {
700
0
                            return;
701
0
                        }
702
                    }
703
0
                }
704
            }
705
            ReadWriteResult::WouldBlock | ReadWriteResult::Err => {
706
                // Sleep some time to avoid hogging 100% CPU usage.
707
0
                tokio::time::sleep(Duration::from_nanos(5)).await;
708
            }
709
            ReadWriteResult::Disconnect => {
710
0
                warn!("Encountered disconnect when trying to read from named pipe");
711
0
                break;
712
            }
713
        }
714
0
        match child.try_wait() {
715
0
            Ok(Some(exit_status)) => match exit_status.code().unwrap() {
716
                0 | 1 | 130 => {
717
                    // 0 -> last command successful
718
                    // 1 -> last command unsuccessful
719
                    // 130 -> last command cancelled (Ctrl + C)
720
0
                    info!(
721
                        "Application terminated, last exit code: {}",
722
0
                        exit_status.code().unwrap()
723
                    );
724
0
                    break;
725
                }
726
                _ => {
727
0
                    if !child_error {
728
0
                        println!("Failed to establish SSH connection: {exit_status}");
729
0
                        println!("Shift-Alt-C to exit");
730
0
                        child_error = true;
731
0
                    }
732
                }
733
            },
734
0
            Ok(None) => (
735
0
                // child is still running
736
0
            ),
737
0
            Err(e) => panic!("{}", e),
738
        }
739
    }
740
0
}
741
742
/// Snapshot the current console color.
743
///
744
/// # Arguments
745
///
746
/// * `api` - The Windows API implementation to use.
747
///
748
/// # Returns
749
///
750
/// `Some(original)` on success, `None` if the buffer info could not be read.
751
0
fn capture_original_console_color(api: &dyn WindowsApi) -> Option<CONSOLE_CHARACTER_ATTRIBUTES> {
752
0
    match api.get_console_screen_buffer_info() {
753
0
        Ok(info) => return Some(info.wAttributes),
754
0
        Err(err) => {
755
0
            warn!(
756
                "Failed to capture original console color; state visuals will be skipped: {}",
757
                err
758
            );
759
0
            return None;
760
        }
761
    }
762
0
}
763
764
/// Splits `host` on its trailing `:port` suffix (if any) and parses
765
/// the port. An invalid `:port` is logged and treated as absent so
766
/// the CLI port can still apply.
767
///
768
/// # Arguments
769
///
770
/// * `host` - Raw host argument, optionally with `:port` suffix.
771
///
772
/// # Returns
773
///
774
/// `(host_without_port, inline_port)`.
775
0
fn split_host_and_inline_port(host: &str) -> (&str, Option<u16>) {
776
0
    let (bare_host, port_str) = host
777
0
        .rsplit_once(':')
778
0
        .map_or((host, None), |(h, p)| return (h, Some(p)));
779
0
    let inline_port = port_str.and_then(|p| {
780
0
        return p
781
0
            .parse::<u16>()
782
0
            .map_err(|e| {
783
0
                warn!("Invalid port '{}': {}. Using default SSH port.", p, e);
784
0
            })
785
0
            .ok();
786
0
    });
787
0
    return (bare_host, inline_port);
788
0
}
789
790
/// Builds the console window title shown to the user.
791
///
792
/// # Arguments
793
///
794
/// * `resolved_username` - Username after SSH config resolution.
795
/// * `host`              - Bare hostname.
796
/// * `port`              - Effective port (inline or CLI), if any.
797
///
798
/// # Returns
799
///
800
/// The console title string in `cssh-rs - user@host[:port]` form.
801
0
fn build_console_title(resolved_username: &str, host: &str, port: Option<u16>) -> String {
802
0
    let title_host = if let Some(port) = port {
803
0
        format!("{host}:{port}")
804
    } else {
805
0
        host.to_string()
806
    };
807
0
    return format!("{PACKAGE_NAME} - {resolved_username}@{title_host}");
808
0
}
809
810
/// Keeps the console window title pinned to `console_title`, since
811
/// the SSH child can overwrite it on connect.
812
///
813
/// # Arguments
814
///
815
/// * `api`           - The Windows API implementation to use.
816
/// * `console_title` - The title to (re)apply.
817
0
async fn run_title_loop(api: &dyn WindowsApi, console_title: String) {
818
    loop {
819
0
        if console_title != get_console_title(api) {
820
0
            api.set_console_title(console_title.as_str())
821
0
                .unwrap_or_else(|err| {
822
0
                    error!("Failed to set console title: {}", err);
823
0
                });
824
0
        }
825
0
        tokio::time::sleep(Duration::from_millis(5)).await;
826
    }
827
}
828
829
/// Drives the per-client console color: tracks `state_receiver` and
830
/// `highlight_receiver`, paints the steady-state combination, and flashes
831
/// the underlying state color for [`HIGHLIGHT_FLASH_DURATION`].
832
///
833
/// # Arguments
834
///
835
/// * `api`                       - The Windows API implementation to use.
836
/// * `state_receiver`                  - Receiver for daemon-driven state changes.
837
/// * `highlight_receiver`              - Receiver for submenu highlight transitions.
838
/// * `original_console_color`    - Pristine attributes; `None`
839
///                                 degrades all painting to a no-op.
840
/// * `disabled_console_color`    - Color for [`ClientState::Disabled`].
841
/// * `highlighted_console_color` - Color while highlighted; overrides
842
///                                 the disabled color.
843
1
async fn run_visuals_loop(
844
1
    api: &dyn WindowsApi,
845
1
    mut state_receiver: watch::Receiver<ClientState>,
846
1
    mut highlight_receiver: watch::Receiver<bool>,
847
1
    original_console_color: Option<CONSOLE_CHARACTER_ATTRIBUTES>,
848
1
    disabled_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
849
1
    highlighted_console_color: CONSOLE_CHARACTER_ATTRIBUTES,
850
1
) {
851
1
    let palette = ConsolePalette {
852
1
        original: original_console_color,
853
1
        disabled: disabled_console_color,
854
1
        highlighted: highlighted_console_color,
855
1
    };
856
1
    let mut prev_state = *state_receiver.borrow_and_update();
857
1
    let mut prev_highlight = *highlight_receiver.borrow_and_update();
858
1
    let mut last_painted: Option<CONSOLE_CHARACTER_ATTRIBUTES> = None;
859
1
    let mut flash_until: Option<tokio::time::Instant> = None;
860
861
1
    paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
862
863
    loop {
864
        // Independent watch channels: `state_receiver` and `highlight_receiver` may be observed out of send-order, so the flash branch can fire (or not) on stale `prev_highlight`.
865
3
        tokio::select! {
866
3
            
state_changed1
= state_receiver.changed() => {
867
1
                if state_changed.is_err() {
868
0
                    return;
869
1
                }
870
1
                prev_state = *state_receiver.borrow_and_update();
871
1
                if prev_highlight {
872
1
                    flash_until = Some(start_flash(api, prev_state, &palette, &mut last_painted));
873
1
                } else {
874
0
                    paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
875
0
                    flash_until = None;
876
0
                }
877
            }
878
3
            
highlight_changed1
= highlight_receiver.changed() => {
879
1
                if highlight_changed.is_err() {
880
1
                    return;
881
0
                }
882
0
                let next_highlight = *highlight_receiver.borrow_and_update();
883
0
                if next_highlight == prev_highlight {
884
0
                    continue;
885
0
                }
886
0
                prev_highlight = next_highlight;
887
0
                flash_until = None;
888
0
                paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
889
            }
890
3
            _ = async {
891
3
                match flash_until {
892
1
                    Some(deadline) => tokio::time::sleep_until(deadline).await,
893
2
                    None => std::future::pending::<()>().await,
894
                }
895
1
            } => {
896
1
                flash_until = None;
897
1
                paint_steady(api, prev_state, prev_highlight, &palette, &mut last_painted);
898
1
            }
899
        }
900
    }
901
1
}
902
903
/// The entrypoint for the `client` subcommand with API dependency injection.
904
///
905
/// Spawns a tokio background thread to ensure the console window title is not replaced
906
/// by the name of the child process once its launched.
907
/// Starts the SSH process as child process.
908
/// Executes the main run loop.
909
///
910
/// # Arguments
911
///
912
/// * `api`         - The Windows API implementation to use.
913
/// * `host`        - The name of the host to connect to, optionally with `:port` suffix.
914
/// * `username`    - The username to be used.
915
///                   Will try to resolve the correct username from the ssh config
916
///                   if none is given.
917
/// * `cli_port`    - Optional port from CLI option. Inline port takes precedence.
918
/// * `config`      - A reference to the `ClientConfig`.
919
0
pub async fn main(
920
0
    api: &dyn WindowsApi,
921
0
    host: String,
922
0
    username: Option<String>,
923
0
    cli_port: Option<u16>,
924
0
    config: &ClientConfig,
925
0
) {
926
    // Shield this client from relayed CTRL+C/CTRL+Break so only the SSH child reacts.
927
0
    if let Err(err) = api.install_console_ctrl_handler() {
928
0
        warn!("Failed to install console control handler: {}", err);
929
0
    }
930
931
0
    let original_console_color = capture_original_console_color(api);
932
933
0
    let (state_sender, state_receiver) = watch::channel(ClientState::Active);
934
0
    let (highlight_sender, highlight_receiver) = watch::channel(false);
935
936
0
    let (host, inline_port) = split_host_and_inline_port(&host);
937
0
    let port = inline_port.or(cli_port);
938
939
0
    let resolved_username = resolve_username(username, host, config);
940
0
    let console_title = build_console_title(&resolved_username, host, port);
941
942
0
    let title_task = run_title_loop(api, console_title);
943
0
    let child_task = async {
944
0
        let mut child = launch_ssh_process(&resolved_username, host, port, config).await;
945
0
        run(api, &mut child, &state_sender, &highlight_sender).await;
946
0
        return child;
947
0
    };
948
0
    let visuals_task = run_visuals_loop(
949
0
        api,
950
0
        state_receiver,
951
0
        highlight_receiver,
952
0
        original_console_color,
953
0
        CONSOLE_CHARACTER_ATTRIBUTES(config.disabled_console_color),
954
0
        CONSOLE_CHARACTER_ATTRIBUTES(config.highlighted_console_color),
955
    );
956
957
    // The title and visuals tasks are infinite by construction; if either
958
    // ever completes, that is a logic bug, not a shutdown path.
959
0
    let mut child = tokio::select! {
960
0
        child = child_task => child,
961
0
        _ = title_task => {
962
0
            panic!("Title task should never complete");
963
        }
964
0
        _ = visuals_task => {
965
0
            panic!("Visuals task should never complete");
966
        }
967
    };
968
969
0
    shutdown_child(api, &mut child).await;
970
0
}
971
972
#[cfg(test)]
973
#[path = "../tests/client/test_mod.rs"]
974
mod test_mod;